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
15 changes: 15 additions & 0 deletions .changeset/auto-claim-hook-wiring-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@colony/storage": patch
"@colony/installers": patch
"@colony/hooks": patch
"@colony/mcp-server": patch
"@imdeadpool/colony-cli": patch
---

Make PreToolUse auto-claim coverage observable and surface hook-wiring problems instead of agent-discipline ones.

- The Claude installer now scopes PreToolUse and PostToolUse to a write-tool matcher so the hook does not fire (or get blamed) for unrelated tools.
- `colony hook run pre-tool-use` now writes its warning back through Claude Code's PreToolUse `permissionDecision: allow` so the agent sees the missing-claim warning instead of it being silently dropped on stderr.
- The pre-tool-use warning embeds a concrete `next_call` (an exact `mcp__colony__task_claim_file({...})` invocation) and a multi-line actionable `message`, so an agent that hits ACTIVE_TASK_NOT_FOUND / AMBIGUOUS_ACTIVE_TASK / SESSION_NOT_FOUND knows exactly what to do.
- `claimBeforeEditStats` adds a `pre_tool_use_signals` count of `claim-before-edit` telemetry rows in the window. `colony health` and `hivemind_context`'s claim-before-edit nudge use it to distinguish "hook is not firing" from "agent skipped the claim", and emit an install/restart hint in the former case.
- `colony health` also reports explicit/manual vs auto-claim breakdown and reads "had a claim before edit" instead of "explicit claims first".
46 changes: 40 additions & 6 deletions apps/cli/src/commands/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ interface ClaimBeforeEditPayload extends ClaimBeforeEditStats {
auto_claimed_before_edit: number;
edits_without_claim_before: number;
claim_before_edit_ratio: number | null;
/** Total claim-before-edit telemetry rows in window — non-zero means the
* PreToolUse hook is firing somewhere; zero with edits > 0 strongly
* suggests the hook is not wired into the active editor session. */
pre_tool_use_signals: number;
/** True when edits happened but no PreToolUse telemetry was recorded —
* diagnostic that points at hook wiring rather than agent discipline. */
likely_missing_hook: boolean;
/** User-facing remediation when likely_missing_hook is true. */
install_hint: string | null;
}

interface SignalHealthPayload {
Expand Down Expand Up @@ -606,12 +615,19 @@ function claimBeforeEditPayload(
): ClaimBeforeEditPayload {
const editsWithoutClaimBefore = stats.edits_with_file_path - stats.edits_claimed_before;
const autoClaimedBeforeEdit = stats.auto_claimed_before_edit ?? 0;
const preToolUseSignals = stats.pre_tool_use_signals ?? 0;
const status =
stats.edit_tool_calls === 0
? 'no_data'
: stats.edit_tool_calls === stats.edits_with_file_path
? 'available'
: 'not_available';
// If edits landed but no claim-before-edit observation was ever written,
// PreToolUse is almost certainly not firing for the active editor.
const likelyMissingHook = stats.edit_tool_calls > 0 && preToolUseSignals === 0;
const installHint = likelyMissingHook
? 'PreToolUse auto-claim is not covering edits in this window. Run colony install --ide <ide>, restart the editor session, and ensure an active task is bound for the session.'
: null;
return {
...stats,
status,
Expand All @@ -622,6 +638,9 @@ function claimBeforeEditPayload(
edits_without_claim_before: editsWithoutClaimBefore,
claim_before_edit_ratio:
status === 'available' ? ratio(stats.edits_claimed_before, stats.edits_with_file_path) : null,
pre_tool_use_signals: preToolUseSignals,
likely_missing_hook: likelyMissingHook,
install_hint: installHint,
};
}

Expand Down Expand Up @@ -751,15 +770,24 @@ function formatClaimBeforeEdit(payload: ClaimBeforeEditPayload): string[] {
);
return lines;
}
const explicitClaims = Math.max(
payload.edits_claimed_before - payload.auto_claimed_before_edit,
0,
);
lines.push(
` ${payload.edits_claimed_before} / ${payload.edits_with_file_path} edits had explicit claims first (${formatPercent(
` ${payload.edits_claimed_before} / ${payload.edits_with_file_path} edits had a claim before edit (${formatPercent(
payload.claim_before_edit_ratio,
)})`,
);
lines.push(` explicit/manual claims before edit: ${explicitClaims}`);
lines.push(` auto-claimed before edit: ${payload.auto_claimed_before_edit}`);
lines.push(` missing proactive claim: ${payload.edits_without_claim_before}`);
lines.push(
` telemetry: edits_with_claim=${payload.edits_with_claim}, edits_missing_claim=${payload.edits_missing_claim}, auto_claimed_before_edit=${payload.auto_claimed_before_edit}`,
` telemetry: edits_with_claim=${payload.edits_with_claim}, edits_missing_claim=${payload.edits_missing_claim}, auto_claimed_before_edit=${payload.auto_claimed_before_edit}, pre_tool_use_signals=${payload.pre_tool_use_signals}`,
);
if (payload.likely_missing_hook && payload.install_hint) {
lines.push(kleur.yellow(` ${payload.install_hint}`));
}
return lines;
}

Expand Down Expand Up @@ -819,17 +847,23 @@ function healthActionHints(payload: ColonyHealthPayloadWithoutHints): ActionHint
TARGET_CLAIM_BEFORE_EDIT,
)
) {
const missingHook = payload.task_claim_file_before_edits.likely_missing_hook;
hints.push({
metric: 'claim-before-edit',
status: 'bad',
current: formatPercent(payload.task_claim_file_before_edits.claim_before_edit_ratio),
target: `${formatPercent(TARGET_CLAIM_BEFORE_EDIT)}+`,
action: 'Call task_claim_file for touched files before Edit or Write tool use.',
action: missingHook
? 'PreToolUse auto-claim hook is not firing for these edits. Reinstall and restart the editor; PreToolUse will auto-claim before edits.'
: 'Call task_claim_file for touched files before Edit or Write tool use.',
tool_call:
'mcp__colony__task_claim_file({ task_id: <task_id>, session_id: "<session_id>", file_path: "<file>", note: "pre-edit claim" })',
command: 'colony install --ide <ide> # enables pre-edit auto-claim hooks',
prompt:
'Before editing, call mcp__colony__task_claim_file for each touched path; if agents keep missing this, run colony install --ide <ide> to enable pre-edit auto-claim hooks.',
command: missingHook
? 'colony install --ide <ide> # then restart the editor session'
: 'colony install --ide <ide> # enables pre-edit auto-claim hooks',
prompt: missingHook
? 'PreToolUse auto-claim is not covering edits — run colony install --ide <ide>, restart the editor session, and ensure an active task is bound. Until the hook fires, call mcp__colony__task_claim_file before each edit.'
: 'Before editing, call mcp__colony__task_claim_file for each touched path; if agents keep missing this, run colony install --ide <ide> to enable pre-edit auto-claim hooks.',
});
}

Expand Down
35 changes: 25 additions & 10 deletions apps/cli/src/commands/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,33 @@ export function registerHookCommand(program: Command): void {
}

function writeIdeOutput(hook: HookName, result: HookResult): void {
// Only SessionStart and UserPromptSubmit can usefully feed text back into
// the agent. For other hooks we deliberately stay silent on stdout.
if (hook !== 'session-start' && hook !== 'user-prompt-submit') return;
const ctx = result.context?.trim();
if (!ctx) return;
const payload = {
hookSpecificOutput: {
hookEventName: CLAUDE_EVENT_NAME[hook],
additionalContext: ctx,
},
};
process.stdout.write(`${JSON.stringify(payload)}\n`);

if (hook === 'session-start' || hook === 'user-prompt-submit') {
const payload = {
hookSpecificOutput: {
hookEventName: CLAUDE_EVENT_NAME[hook],
additionalContext: ctx,
},
};
process.stdout.write(`${JSON.stringify(payload)}\n`);
return;
}

// PreToolUse: surface the auto-claim warning to the agent via
// permissionDecisionReason while keeping the call allowed — we never block
// edits, we annotate so the agent learns the missing claim.
if (hook === 'pre-tool-use') {
const payload = {
hookSpecificOutput: {
hookEventName: CLAUDE_EVENT_NAME[hook],
permissionDecision: 'allow',
permissionDecisionReason: ctx,
},
};
process.stdout.write(`${JSON.stringify(payload)}\n`);
}
}

function safeJson(s: string): Record<string, unknown> {
Expand Down
13 changes: 9 additions & 4 deletions apps/cli/test/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ describe('colony health payload', () => {
expect(text).toContain('task_post vs task_message');
expect(text).toContain('task_post vs OMX notepad');
expect(text).toContain('Search calls per session');
expect(text).toContain('1 / 2 edits had explicit claims first (50%)');
expect(text).toContain('1 / 2 edits had a claim before edit (50%)');
expect(text).toContain('Signal health');
expect(text).toContain('Proposal decay/promotions');
expect(text).toContain('Ready-to-claim vs claimed');
Expand Down Expand Up @@ -526,10 +526,13 @@ describe('colony health payload', () => {
metric: 'claim-before-edit',
current: '0%',
target: '50%+',
action: expect.stringContaining('task_claim_file'),
// Action wording branches on whether PreToolUse telemetry exists; both
// branches reference task_claim_file, but the missing-hook branch
// recommends reinstalling the hook before relying on agent discipline.
action: expect.stringContaining('PreToolUse auto-claim hook is not firing'),
tool_call: expect.stringContaining('mcp__colony__task_claim_file'),
command: expect.stringContaining('colony install --ide <ide>'),
prompt: expect.stringContaining('pre-edit auto-claim hooks'),
prompt: expect.stringContaining('PreToolUse auto-claim is not covering edits'),
}),
expect.objectContaining({
metric: 'stale claims',
Expand Down Expand Up @@ -561,7 +564,9 @@ describe('colony health payload', () => {
expect(text).toContain(
'task_ready_for_agent -> claim: 0% (target 30%+) - When ready work fits',
);
expect(text).toContain('claim-before-edit: 0% (target 50%+) - Call task_claim_file');
expect(text).toContain(
'claim-before-edit: 0% (target 50%+) - PreToolUse auto-claim hook is not firing',
);
expect(text).toContain(
'tool: mcp__colony__task_claim_file({ task_id: <task_id>, session_id: "<session_id>", file_path: "<file>", note: "pre-edit claim" })',
);
Expand Down
9 changes: 7 additions & 2 deletions apps/mcp-server/src/tools/hivemind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,11 +330,16 @@ function adoptionNudgesFromMetrics(store: MemoryStore, now: number): HivemindAdo
claimBeforeEditRatio !== null &&
claimBeforeEditRatio < TARGET_CLAIM_BEFORE_EDIT
) {
const preToolUseSignals = claimStats.pre_tool_use_signals ?? 0;
const likelyMissingHook =
claimStats.edits_claimed_before === 0 && preToolUseSignals === 0;
nudges.push({
key: 'claim_before_edit_low',
tool: 'task_claim_file',
current: `claimed_before_edit=${claimStats.edits_claimed_before}/${claimStats.edits_with_file_path}`,
hint: 'Call task_claim_file for touched files before edit tools.',
current: `claimed_before_edit=${claimStats.edits_claimed_before}/${claimStats.edits_with_file_path}; pre_tool_use_signals=${preToolUseSignals}`,
hint: likelyMissingHook
? 'PreToolUse auto-claim is not covering edits. Run colony install --ide <ide>, restart the editor, or call task_claim_file before editing.'
: 'Call task_claim_file for touched files before edit tools.',
});
}
} catch {
Expand Down
4 changes: 3 additions & 1 deletion apps/mcp-server/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,9 @@ describe('MCP server', () => {
expect.objectContaining({
key: 'claim_before_edit_low',
tool: 'task_claim_file',
current: 'claimed_before_edit=0/1',
// Compact diagnostic now appends pre_tool_use_signals so agents can
// distinguish missing-hook (=0) from missed-by-agent (>0) at a glance.
current: 'claimed_before_edit=0/1; pre_tool_use_signals=0',
}),
]);
expect(payload.summary.suggested_tools).toEqual([
Expand Down
32 changes: 28 additions & 4 deletions packages/hooks/src/handlers/pre-tool-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface ClaimBeforeEditFallbackWarning {
code: AutoClaimFailureCode;
message: string;
next_tool: 'task_claim_file';
/** Concrete invocation string the agent can copy verbatim. */
next_call: string;
suggested_args: {
task_id: number | '<task_id>' | '<candidate.task_id>';
session_id: string;
Expand All @@ -40,18 +42,18 @@ type CompactCandidate = Pick<
>;

export function preToolUse(store: MemoryStore, input: HookInput): string {
const toolName = input.tool_name ?? input.tool ?? '';
try {
return claimBeforeEditWarning(claimBeforeEditFromToolUse(store, input));
} catch {
const toolName = input.tool_name ?? input.tool ?? '';
const files = extractTouchedFiles(toolName, input.tool_input);
return claimBeforeEditWarning({
files,
edits_with_claim: [],
edits_missing_claim: files,
auto_claimed_before_edit: [],
warnings: files.map((file_path) =>
claimWarning(input.session_id, file_path, {
claimWarning(input.session_id, file_path, toolName, {
ok: false,
code: 'COLONY_UNAVAILABLE',
error: 'Colony unavailable for auto-claim',
Expand Down Expand Up @@ -121,7 +123,8 @@ export function claimBeforeEditFromToolUse(
error: claim.error,
candidates: claim.candidates,
});
if (!warningDebounced) result.warnings.push(claimWarning(input.session_id, file_path, claim));
if (!warningDebounced)
result.warnings.push(claimWarning(input.session_id, file_path, toolName, claim));
}

return result;
Expand Down Expand Up @@ -221,13 +224,34 @@ function claimBeforeEditWarning(result: ClaimBeforeEditResult): string {
function claimWarning(
session_id: string,
file_path: string,
tool_name: string,
claim: AutoClaimFailure,
): ClaimBeforeEditFallbackWarning {
const candidates = compactCandidates(claim.candidates);
// Spell out the exact tool call the agent should make next. When there is
// exactly one candidate task we substitute its id; ambiguous candidates and
// no-candidate cases keep a placeholder so the agent picks consciously.
const taskRef =
candidates.length === 1
? String(candidates[0]?.task_id ?? '<task_id>')
: candidates.length > 0
? '<candidate.task_id>'
: '<task_id>';
const next_call = `mcp__colony__task_claim_file({ task_id: ${taskRef}, session_id: "${session_id}", file_path: "${file_path}", note: "pre-edit claim" })`;
const tool = tool_name || 'edit tool';
const message = [
`Missing Colony claim before ${tool} on ${file_path}.`,
`reason=${claim.code}: ${claim.error}`,
`next=${next_call}`,
candidates.length > 0 ? `candidates=${JSON.stringify(candidates)}` : '',
]
.filter(Boolean)
.join('\n');
return {
code: claim.code,
message: `Missing Colony claim before edit. Call task_claim_file for ${file_path} before editing.`,
message,
next_tool: 'task_claim_file',
next_call,
suggested_args: {
task_id: candidates.length > 0 ? '<candidate.task_id>' : '<task_id>',
session_id,
Expand Down
Loading
Loading