From e7fa9668887bce35a9ba5d4fd250cb85d54d0eff Mon Sep 17 00:00:00 2001 From: Yoni Davidson Date: Wed, 8 Jul 2026 17:51:33 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20real=20auto-status=20from=20the=20task?= =?UTF-8?q?=20list=20=E2=80=94=20drop=20the=20"on=20main"=20filler=20(0.16?= =?UTF-8?q?.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three agents all showing "on main" proved the mechanical branch default was noise, not signal: it looks like a status without being one. Fixed at the source — the status now comes from the agent's OWN work: - TaskCreated/TaskCompleted hooks mirror the task subject into the bus status ("implement the auth endpoints", "done: review PR 5920") — real, self-authored, self-updating, zero extra effort. This is the "make it better" answer: the board reflects what agents are actually doing. - Branch default removed entirely; a session with no task and no declaration renders honestly ("— no task set"), never a fake status. - Heartbeats stay statusless (Bus preserves whatever the task list or an explicit declaration set). Nudge fires only when truly statusless. - Skill: bus activity + others' statuses are "a teammate talking" — act in the same turn when work overlaps/unblocks/duplicates, tell the user. (Acting is model judgment; the wording is now imperative, not passing.) Test: TaskCreated sets the subject as status, TaskCompleted shows "done: …", no-subject/off-bus is silent. Co-Authored-By: Claude Fable 5 --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- docs/app.js | 2 +- hooks/hooks.json | 22 ++++++++++++++++++ hooks/lib.mjs | 30 ------------------------- hooks/prompt-digest.mjs | 21 ++++++++--------- hooks/session-start.mjs | 19 ++++------------ hooks/task-status.mjs | 22 ++++++++++++++++++ package.json | 2 +- skills/agentcomm/SKILL.md | 19 +++++++++------- test/hooks.e2e.test.ts | 40 +++++++++++++++------------------ 11 files changed, 90 insertions(+), 91 deletions(-) create mode 100644 hooks/task-status.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6570e0d..0557fe2 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "agentcomm", "source": "./", "description": "Multi-agent mailbox CLI — any git remote is a bus (git+ssh/github/sqlite/s3/gcs/postgres). register/send/inbox/wait/claim/log/channels so Claude coordinates with other agents or sessions; auto-selects the repo bus inside a git repo.", - "version": "0.15.0", + "version": "0.16.0", "author": { "name": "Yoni Davidson" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index bfa7e30..88478c5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agentcomm", "description": "Multi-agent mailbox CLI — any git remote is a bus (git+ssh/github/sqlite/s3/gcs/postgres). register/send/inbox/wait/claim/log/channels so Claude coordinates with other agents or sessions; auto-selects the repo bus inside a git repo.", - "version": "0.15.0", + "version": "0.16.0", "author": { "name": "Yoni Davidson", "email": "yonidavidson@tabnine.com" diff --git a/docs/app.js b/docs/app.js index 2c428c7..b0fde2d 100644 --- a/docs/app.js +++ b/docs/app.js @@ -57,7 +57,7 @@ name.append(dot, document.createTextNode(a.name)); const status = document.createElement('span'); status.className = 'board-status' + (a.status ? '' : ' idle'); - status.textContent = a.status || '(no status declared)'; + status.textContent = a.status || '— no task set'; const seen = document.createElement('span'); seen.className = 'board-seen'; seen.textContent = rel(a.lastSeen); diff --git a/hooks/hooks.json b/hooks/hooks.json index 8571d5e..18e8916 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -44,6 +44,28 @@ } ] } + ], + "TaskCreated": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/task-status.mjs\"", + "timeout": 10 + } + ] + } + ], + "TaskCompleted": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/task-status.mjs\"", + "timeout": 10 + } + ] + } ] } } diff --git a/hooks/lib.mjs b/hooks/lib.mjs index ecb0894..543b5c4 100644 --- a/hooks/lib.mjs +++ b/hooks/lib.mjs @@ -128,36 +128,6 @@ export async function activitySince(cwd, me, stateFile, cap = 4) { }; } -/** Current git branch (mechanical, non-prompt), or null. */ -export async function gitBranch(cwd) { - try { - // symbolic-ref works on unborn branches too (rev-parse HEAD needs a commit) - const b = await new Promise((res, rej) => - execFile('git', ['-C', cwd, 'symbolic-ref', '--short', 'HEAD'], (e, out) => - e ? rej(e) : res(out.trim()), - ), - ); - return b && b !== 'HEAD' ? b : null; - } catch { - return null; - } -} - -/** - * Extra register args that give the board a mechanical default status — - * "on " — WITHOUT clobbering a status the agent actually declared. - * Set it only when the record has no status, or when the existing status is - * itself a branch-default (so it follows branch switches). A real declared - * status (anything not matching the branch pattern) is left untouched. - */ -export function branchStatusArgs(currentStatus, branch) { - if (!branch) return []; - const isBranchDefault = currentStatus == null || /^on \S+$/.test(currentStatus); - if (!isBranchDefault) return []; - if (currentStatus === `on ${branch}`) return []; // already correct, no write - return ['--status', `on ${branch}`]; -} - export function aliasFrom(stderr) { const m = /acting as (\S+)/.exec(stderr ?? ''); return m?.[1] ?? null; diff --git a/hooks/prompt-digest.mjs b/hooks/prompt-digest.mjs index 859ec26..94583d0 100644 --- a/hooks/prompt-digest.mjs +++ b/hooks/prompt-digest.mjs @@ -10,7 +10,7 @@ import { promises as fs } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { createHash } from 'node:crypto'; -import { readStdinJson, onTheBus, cli, aliasFrom, activitySince, gitBranch, branchStatusArgs } from './lib.mjs'; +import { readStdinJson, onTheBus, cli, aliasFrom, activitySince } from './lib.mjs'; const input = await readStdinJson(); const cwd = input.cwd || process.cwd(); @@ -23,14 +23,12 @@ try { if (Date.now() - (await fs.stat(stamp)).mtimeMs < 5 * 60_000) process.exit(0); } catch { /* first digest */ } -// Heartbeat rides the digest (~5min). Fetch roster first to read our own -// status, then register — refreshing the mechanical "on " default -// without ever clobbering a status the agent declared. -const preAgents = await cli(['agents', '--json'], cwd, 3_000); -const myPre = preAgents && Array.isArray(preAgents.json) ? preAgents.json.find((a) => a.thisSession) : null; -await cli(['register', '--json', ...branchStatusArgs(myPre?.status, await gitBranch(cwd))], cwd, 3_000); +// Heartbeat rides the digest (~5min); a plain register keeps the existing +// status (Bus preserves it — statuses come from the task list or explicit +// declaration, never from a heartbeat). +await cli(['register', '--json'], cwd, 3_000); const peek = await cli(['peek', '--json'], cwd, 3_000); -const agents = (await cli(['agents', '--json'], cwd, 3_000)) ?? preAgents; +const agents = await cli(['agents', '--json'], cwd, 3_000); await fs.writeFile(stamp, '').catch(() => {}); if (!peek && !agents) process.exit(0); @@ -63,9 +61,8 @@ const alias0 = aliasFrom(peek?.stderr); // coordination — nudge it (gently: at most once per 30min per repo) let statusNudge = null; const myRec = roster.find((a) => a.name === alias0); -// nudge when there is no status OR only the mechanical branch default — -// agents should upgrade "on " to what they are actually doing -if (myRec && (!myRec.status || /^on \S+$/.test(myRec.status))) { +// nudge only when truly statusless (no task list, no declaration) +if (myRec && !myRec.status) { const nudgeStamp = path.join(os.tmpdir(), `agentcomm-nudge-${id}`); let due = true; try { @@ -73,7 +70,7 @@ if (myRec && (!myRec.status || /^on \S+$/.test(myRec.status))) { } catch { /* first */ } if (due) { statusNudge = - `Your bus status is ${myRec.status ? `just the branch ("${myRec.status}")` : 'unset'} — say what you are actually doing so teammates see it: \`agentcomm register --status ""\` (or "blocked: " to recruit help).`; + 'You have no bus status — set one so teammates see your work: `agentcomm register --status ""` (it also auto-follows your task list). "blocked: " recruits help.'; await fs.writeFile(nudgeStamp, '').catch(() => {}); } } diff --git a/hooks/session-start.mjs b/hooks/session-start.mjs index 05c48d5..09fdae5 100644 --- a/hooks/session-start.mjs +++ b/hooks/session-start.mjs @@ -5,7 +5,7 @@ * its derived session alias, and the live roster — before the first prompt. * Silent everywhere else; silent on any failure. */ -import { readStdinJson, onTheBus, cli, busUriFrom, aliasFrom, gitBranch, branchStatusArgs } from './lib.mjs'; +import { readStdinJson, onTheBus, cli, busUriFrom, aliasFrom } from './lib.mjs'; const input = await readStdinJson(); const cwd = input.cwd || process.cwd(); @@ -25,22 +25,11 @@ try { fresh = Date.now() - (await fsp.stat(stamp)).mtimeMs < 10 * 60_000; } catch { /* never registered from here */ } -// Know our current status before registering, so a mechanical branch -// default never clobbers a real one the agent declared. -const pre = await cli(['agents', '--json'], cwd, 20_000); -const branch = await gitBranch(cwd); -let myStatus; -if (pre && Array.isArray(pre.json)) { - const mine = pre.json.find((a) => a.thisSession); - myStatus = mine?.status; -} -const reg = fresh - ? null - : await cli(['register', '--json', ...branchStatusArgs(myStatus, branch)], cwd, 20_000); +const reg = fresh ? null : await cli(['register', '--json'], cwd, 20_000); if (reg) await fsp.writeFile(stamp, '').catch(() => {}); const peek = await cli(['peek', '--json'], cwd); if (!peek) process.exit(0); -const res = (await cli(['agents', '--json'], cwd)) ?? pre; +const res = await cli(['agents', '--json'], cwd); const bus = busUriFrom(peek.stderr) ?? busUriFrom(reg?.stderr); const alias = aliasFrom(peek.stderr) ?? reg?.json?.name; @@ -65,7 +54,7 @@ const lines = [ (a) => `call to action — ${a.name} is asking: "${a.status}". If you can answer from what you already know, reply: \`agentcomm send ${a.name} "" --subject status\`.`, ), - `As your FIRST action, declare what you are actually doing so teammates can see it: \`agentcomm register --status ""\`${branch ? ` (currently shown as "on ${branch}")` : ''}. Update it when your task changes; "blocked: " recruits help.`, + 'Your status shows on the shared board — it auto-follows your task list (TaskCreate), or set it explicitly: `agentcomm register --status ""`; "blocked: " recruits help.', 'To coordinate: `agentcomm send ` / `inbox --json` / `wait`; the agentcomm skill has the conventions.', ].filter(Boolean); diff --git a/hooks/task-status.mjs b/hooks/task-status.mjs new file mode 100644 index 0000000..f6909a4 --- /dev/null +++ b/hooks/task-status.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node +/** + * TaskCreated / TaskCompleted hook: mirror the agent's own task into its bus + * status — a REAL, self-authored "what I'm doing" that needs no extra action. + * "implement auth endpoints" beats "on main". Fires only in opted-in repos; + * silent on any failure; the write is async through the daemon outbox (~0.2s). + */ +import { readStdinJson, onTheBus, cli } from './lib.mjs'; + +const input = await readStdinJson(); +const cwd = input.cwd || process.cwd(); +if (!(await onTheBus(cwd))) process.exit(0); + +const subject = (input.task_subject ?? '').trim(); +if (!subject) process.exit(0); + +const status = + input.hook_event_name === 'TaskCompleted' ? `done: ${subject}` : subject; +const clipped = status.length > 80 ? status.slice(0, 79) + '…' : status; + +await cli(['register', '--status', clipped], cwd, 3_000); +process.exit(0); diff --git a/package.json b/package.json index d07e623..b8bb3a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agentcomm", - "version": "0.15.0", + "version": "0.16.0", "description": "A tiny mailbox/message bus for AI agents that shell out to one CLI. Pluggable storage backends behind a single Backend interface.", "type": "module", "license": "MIT", diff --git a/skills/agentcomm/SKILL.md b/skills/agentcomm/SKILL.md index 1d1ec3c..1aa15ca 100644 --- a/skills/agentcomm/SKILL.md +++ b/skills/agentcomm/SKILL.md @@ -154,10 +154,13 @@ the discipline mechanical — don't duplicate them: your presence and — only when there is news — may appear in your context; during LONG turns a mid-task digest (≤ once per 10min, via tool results) keeps you reachable: act on its unread/ask signals per the same rules. - Digests carry a bus-activity feed (recent messages, including between - other agents — the bus is a trusted space): read it and ACT when it - affects you — someone finished what you depend on, duplicates your work, - or corrected something you're using. Ignore what doesn't touch your task — unread count, riders that + Digests carry a bus-activity feed and other agents' live statuses + (the bus is a trusted space). This is not background chatter — treat it + like a teammate talking: when you see someone doing something that + overlaps, unblocks, duplicates, or contradicts your work, ACT on it — + send them what they need, ask for what they have, or adjust course, in + the same turn. Say so to your user ("worker-2 is on the schema I need — + asking them"). Only ignore what genuinely doesn't touch your task — unread count, riders that joined, and what active agents say they're doing. Act on it like any bus fact; no need to re-check what it reports. @@ -172,10 +175,10 @@ help; check `log --limit 10` first (it may already be answered); reply once, on the asker's thread. When unblocked, change your status back to plain work — that stops the recruiting. -The hooks give you a mechanical starting status — `on ` — so the -roster is never blank, but that only says WHERE you are, not WHAT you are -doing. **Upgrade it at the start of any task** — declaring overrides the -branch default and sticks until you change it: +Your status auto-follows your task list: creating a task (TaskCreate) sets +your bus status to that task's subject, so the shared board reflects what +you are actually doing with no extra effort. Set it explicitly when you +want more precision or aren't using tasks: `agentcomm register --status "reviewing PR 12"` (short, present tense; it persists across heartbeats until you change it — set `--status done` when finishing). An empty status makes you invisible to coordination: nobody diff --git a/test/hooks.e2e.test.ts b/test/hooks.e2e.test.ts index 2853a28..770b8de 100644 --- a/test/hooks.e2e.test.ts +++ b/test/hooks.e2e.test.ts @@ -427,7 +427,7 @@ describe('plugin hooks: bus discipline made mechanical', () => { const first = await runHook('prompt-digest.mjs', { cwd: dir }, dir); const out = JSON.parse(first.stdout) as { hookSpecificOutput: { additionalContext: string } }; - expect(out.hookSpecificOutput.additionalContext).toMatch(/bus status is (unset|just the branch)/); + expect(out.hookSpecificOutput.additionalContext).toContain("no bus status"); // declared → clear throttles → no nudge, and (quiet bus) full silence cliSync(['register', '--status', 'building the feed'], dir); @@ -469,33 +469,29 @@ describe('plugin hooks: bus discipline made mechanical', () => { expect(quiet.stdout).toBe(''); }); - it('session-start sets a mechanical branch-default status; a real status is never clobbered', async () => { + it('a created task auto-sets the bus status; completing it shows "done: …"', async () => { const dir = await markedRepo(); - execFileSync('git', ['-C', dir, 'checkout', '-q', '-b', 'feat-auth']); + cliSync(['register'], dir); // on the roster, no status yet - // fresh session, no status → hook sets "on feat-auth" - await runHook('session-start.mjs', { cwd: dir, source: 'startup' }, dir); + await runHook( + 'task-status.mjs', + { cwd: dir, hook_event_name: 'TaskCreated', task_subject: 'implement the auth endpoints' }, + dir, + ); let me = rosterJson(dir).find((a) => a.name.startsWith('hooky-'))!; - expect(me.status).toBe('on feat-auth'); + expect(me.status).toBe('implement the auth endpoints'); - // agent declares a real status → later session-start must NOT overwrite it - cliSync(['register', '--status', 'reviewing PR 12'], dir); - // clear the register throttle so the hook re-registers - for (const f of await fs.readdir(dir)) { - if (f.startsWith('agentcomm-register-')) await fs.rm(path.join(dir, f)); - } - await runHook('session-start.mjs', { cwd: dir, source: 'startup' }, dir); + await runHook( + 'task-status.mjs', + { cwd: dir, hook_event_name: 'TaskCompleted', task_subject: 'implement the auth endpoints' }, + dir, + ); me = rosterJson(dir).find((a) => a.name.startsWith('hooky-'))!; - expect(me.status).toBe('reviewing PR 12'); // preserved + expect(me.status).toBe('done: implement the auth endpoints'); - // switching branches only moves a branch-DEFAULT status, not a real one - execFileSync('git', ['-C', dir, 'checkout', '-q', '-b', 'feat-other']); - for (const f of await fs.readdir(dir)) { - if (f.startsWith('agentcomm-register-')) await fs.rm(path.join(dir, f)); - } - await runHook('session-start.mjs', { cwd: dir, source: 'startup' }, dir); - me = rosterJson(dir).find((a) => a.name.startsWith('hooky-'))!; - expect(me.status).toBe('reviewing PR 12'); // still the declared one + // silent outside opted-in repos / with no subject + const noSub = await runHook('task-status.mjs', { cwd: dir, hook_event_name: 'TaskCreated' }, dir); + expect(noSub.code).toBe(0); }); it('stop guard honors stop_hook_active (no loops) and throttles repeat checks', async () => {